/*
 * Copyright 2002-2019 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.web.reactive.result.view;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.validation.BindingResult;
import org.springframework.web.reactive.BindingContext;
import org.springframework.web.server.ServerWebExchange;

/**
 * Base class for {@link View} implementations.
 *
 * @author Rossen Stoyanchev
 * @since 5.0
 */
public abstract class AbstractView implements View, BeanNameAware, ApplicationContextAware {

    /**
     * Well-known name for the RequestDataValueProcessor in the bean factory.
     */
    public static final String REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME = "requestDataValueProcessor";


    /**
     * Logger that is available to subclasses.
     */
    protected final Log logger = LogFactory.getLog(getClass());

    private final ReactiveAdapterRegistry adapterRegistry;

    private final List<MediaType> mediaTypes = new ArrayList<>(4);

    private Charset defaultCharset = StandardCharsets.UTF_8;

    @Nullable
    private String requestContextAttribute;

    @Nullable
    private String beanName;

    @Nullable
    private ApplicationContext applicationContext;


    public AbstractView() {
        this(ReactiveAdapterRegistry.getSharedInstance());
    }

    public AbstractView(ReactiveAdapterRegistry reactiveAdapterRegistry) {
        this.adapterRegistry = reactiveAdapterRegistry;
        this.mediaTypes.add(ViewResolverSupport.DEFAULT_CONTENT_TYPE);
    }

    /**
     * Return the configured media types supported by this view.
     */
    @Override
    public List<MediaType> getSupportedMediaTypes() {
        return this.mediaTypes;
    }

    /**
     * Set the supported media types for this view.
     * Default is "text/html;charset=UTF-8".
     */
    public void setSupportedMediaTypes(List<MediaType> supportedMediaTypes) {
        Assert.notEmpty(supportedMediaTypes, "MediaType List must not be empty");
        this.mediaTypes.clear();
        this.mediaTypes.addAll(supportedMediaTypes);
    }

    /**
     * Return the default charset, used when the
     * {@linkplain #setSupportedMediaTypes(List) content type} does not contain one.
     */
    public Charset getDefaultCharset() {
        return this.defaultCharset;
    }

    /**
     * Set the default charset for this view, used when the
     * {@linkplain #setSupportedMediaTypes(List) content type} does not contain one.
     * Default is {@linkplain StandardCharsets#UTF_8 UTF 8}.
     */
    public void setDefaultCharset(Charset defaultCharset) {
        Assert.notNull(defaultCharset, "'defaultCharset' must not be null");
        this.defaultCharset = defaultCharset;
    }

    /**
     * Return the name of the RequestContext attribute, if any.
     */
    @Nullable
    public String getRequestContextAttribute() {
        return this.requestContextAttribute;
    }

    /**
     * Set the name of the RequestContext attribute for this view.
     * Default is none.
     */
    public void setRequestContextAttribute(@Nullable String requestContextAttribute) {
        this.requestContextAttribute = requestContextAttribute;
    }

    /**
     * Return the view's name. Should never be {@code null}, if the view was
     * correctly configured.
     */
    @Nullable
    public String getBeanName() {
        return this.beanName;
    }

    /**
     * Set the view's name. Helpful for traceability.
     * <p>Framework code must call this when constructing views.
     */
    @Override
    public void setBeanName(@Nullable String beanName) {
        this.beanName = beanName;
    }

    @Nullable
    public ApplicationContext getApplicationContext() {
        return this.applicationContext;
    }

    @Override
    public void setApplicationContext(@Nullable ApplicationContext applicationContext) {
        this.applicationContext = applicationContext;
    }

    /**
     * Obtain the ApplicationContext for actual use.
     *
     * @return the ApplicationContext (never {@code null})
     * @throws IllegalStateException in case of no ApplicationContext set
     */
    protected final ApplicationContext obtainApplicationContext() {
        ApplicationContext applicationContext = getApplicationContext();
        Assert.state(applicationContext != null, "No ApplicationContext");
        return applicationContext;
    }


    /**
     * Prepare the model to render.
     *
     * @param model       a Map with name Strings as keys and corresponding model
     *                    objects as values (Map can also be {@code null} in case of empty model)
     * @param contentType the content type selected to render with which should
     *                    match one of the {@link #getSupportedMediaTypes() supported media types}.
     * @param exchange    the current exchange
     * @return {@code Mono} to represent when and if rendering succeeds
     */
    @Override
    public Mono<Void> render(@Nullable Map<String, ?> model, @Nullable MediaType contentType,
                             ServerWebExchange exchange) {

        if (logger.isDebugEnabled()) {
            logger.debug(exchange.getLogPrefix() + "View " + formatViewName() +
                    ", model " + (model != null ? model : Collections.emptyMap()));
        }

        if (contentType != null) {
            exchange.getResponse().getHeaders().setContentType(contentType);
        }

        return getModelAttributes(model, exchange).flatMap(mergedModel -> {
            // Expose RequestContext?
            if (this.requestContextAttribute != null) {
                mergedModel.put(this.requestContextAttribute, createRequestContext(exchange, mergedModel));
            }
            return renderInternal(mergedModel, contentType, exchange);
        });
    }

    /**
     * Prepare the model to use for rendering.
     * <p>The default implementation creates a combined output Map that includes
     * model as well as static attributes with the former taking precedence.
     */
    protected Mono<Map<String, Object>> getModelAttributes(
            @Nullable Map<String, ?> model, ServerWebExchange exchange) {

        Map<String, Object> attributes;
        if (model != null) {
            attributes = new ConcurrentHashMap<>(model.size());
            for (Map.Entry<String, ?> entry : model.entrySet()) {
                if (entry.getValue() != null) {
                    attributes.put(entry.getKey(), entry.getValue());
                }
            }
        }
        else {
            attributes = new ConcurrentHashMap<>(0);
        }

        //noinspection deprecation
        return resolveAsyncAttributes(attributes)
                .then(resolveAsyncAttributes(attributes, exchange))
                .doOnTerminate(() -> exchange.getAttributes().remove(BINDING_CONTEXT_ATTRIBUTE))
                .thenReturn(attributes);
    }

    /**
     * Use the configured {@link ReactiveAdapterRegistry} to adapt asynchronous
     * attributes to {@code Mono<T>} or {@code Mono<List<T>>} and then wait to
     * resolve them into actual values. When the returned {@code Mono<Void>}
     * completes, the asynchronous attributes in the model would have been
     * replaced with their corresponding resolved values.
     *
     * @return result {@code Mono} that completes when the model is ready
     * @since 5.1.8
     */
    protected Mono<Void> resolveAsyncAttributes(Map<String, Object> model, ServerWebExchange exchange) {
        List<Mono<?>> asyncAttributes = null;
        for (Map.Entry<String, ?> entry : model.entrySet()) {
            Object value = entry.getValue();
            if (value == null) {
                continue;
            }
            ReactiveAdapter adapter = this.adapterRegistry.getAdapter(null, value);
            if (adapter != null) {
                if (asyncAttributes == null) {
                    asyncAttributes = new ArrayList<>();
                }
                String name = entry.getKey();
                if (adapter.isMultiValue()) {
                    asyncAttributes.add(
                            Flux.from(adapter.toPublisher(value))
                                    .collectList()
                                    .doOnSuccess(result -> model.put(name, result)));
                }
                else {
                    asyncAttributes.add(
                            Mono.from(adapter.toPublisher(value))
                                    .doOnSuccess(result -> {
                                        if (result != null) {
                                            model.put(name, result);
                                            addBindingResult(name, result, model, exchange);
                                        }
                                        else {
                                            model.remove(name);
                                        }
                                    }));
                }
            }
        }
        return asyncAttributes != null ? Mono.when(asyncAttributes) : Mono.empty();
    }

    private void addBindingResult(String name, Object value, Map<String, Object> model, ServerWebExchange exchange) {
        BindingContext context = exchange.getAttribute(BINDING_CONTEXT_ATTRIBUTE);
        if (context == null || value.getClass().isArray() || value instanceof Collection ||
                value instanceof Map || BeanUtils.isSimpleValueType(value.getClass())) {
            return;
        }
        BindingResult result = context.createDataBinder(exchange, value, name).getBindingResult();
        model.put(BindingResult.MODEL_KEY_PREFIX + name, result);
    }

    /**
     * Use the configured {@link ReactiveAdapterRegistry} to adapt asynchronous
     * attributes to {@code Mono<T>} or {@code Mono<List<T>>} and then wait to
     * resolve them into actual values. When the returned {@code Mono<Void>}
     * completes, the asynchronous attributes in the model would have been
     * replaced with their corresponding resolved values.
     *
     * @return result {@code Mono} that completes when the model is ready
     * @deprecated as of 5.1.8 this method is still invoked but it is a no-op.
     * Please, use {@link #resolveAsyncAttributes(Map, ServerWebExchange)}
     * instead. It is invoked after this one and does the actual work.
     */
    @Deprecated
    protected Mono<Void> resolveAsyncAttributes(Map<String, Object> model) {
        return Mono.empty();
    }

    /**
     * Create a RequestContext to expose under the specified attribute name.
     * <p>The default implementation creates a standard RequestContext instance
     * for the given request and model. Can be overridden in subclasses for
     * custom instances.
     *
     * @param exchange current exchange
     * @param model    combined output Map (never {@code null}),
     *                 with dynamic values taking precedence over static attributes
     * @return the RequestContext instance
     * @see #setRequestContextAttribute
     */
    protected RequestContext createRequestContext(ServerWebExchange exchange, Map<String, Object> model) {
        return new RequestContext(exchange, model, obtainApplicationContext(), getRequestDataValueProcessor());
    }

    /**
     * Return the {@link RequestDataValueProcessor} to use.
     * <p>The default implementation looks in the {@link #getApplicationContext()
     * Spring configuration} for a {@code RequestDataValueProcessor} bean with
     * the name {@link #REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME}.
     *
     * @return the RequestDataValueProcessor, or null if there is none at the
     * application context.
     */
    @Nullable
    protected RequestDataValueProcessor getRequestDataValueProcessor() {
        ApplicationContext context = getApplicationContext();
        if (context != null && context.containsBean(REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME)) {
            return context.getBean(REQUEST_DATA_VALUE_PROCESSOR_BEAN_NAME, RequestDataValueProcessor.class);
        }
        return null;
    }

    /**
     * Subclasses must implement this method to actually render the view.
     *
     * @param renderAttributes combined output Map (never {@code null}),
     *                         with dynamic values taking precedence over static attributes
     * @param contentType      the content type selected to render with which should
     *                         match one of the {@link #getSupportedMediaTypes() supported media types}.
     * @param exchange         current exchange  @return {@code Mono} to represent when
     *                         and if rendering succeeds
     */
    protected abstract Mono<Void> renderInternal(Map<String, Object> renderAttributes,
                                                 @Nullable MediaType contentType, ServerWebExchange exchange);


    @Override
    public String toString() {
        return getClass().getName() + ": " + formatViewName();
    }

    protected String formatViewName() {
        return (getBeanName() != null ?
                "name '" + getBeanName() + "'" : "[" + getClass().getSimpleName() + "]");
    }

}
